All files / web/src/app/api/curriculum/[playerId]/sessions/[sessionId]/videos route.ts

0% Statements 0/129
0% Branches 0/1
0% Functions 0/1
0% Lines 0/129

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130                                                                                                                                                                                                                                                                   
/**
 * API route for listing per-problem vision recording videos for a session
 *
 * GET /api/curriculum/[playerId]/sessions/[sessionId]/videos
 *
 * Returns a list of available problem videos for the session.
 */

export const dynamic = 'force-dynamic'

import { and, asc, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { db } from '@/db'
import { sessionPlans, visionProblemVideos } from '@/db/schema'
import { withAuth } from '@/lib/auth/withAuth'
import { generateAuthorizationError, getPlayerAccess } from '@/lib/classroom'
import { getUserId } from '@/lib/viewer'

/**
 * GET - List available problem videos for a session
 */
export const GET = withAuth(async (_request, { params }) => {
  try {
    const { playerId, sessionId } = (await params) as { playerId: string; sessionId: string }

    if (!playerId || !sessionId) {
      return NextResponse.json({ error: 'Player ID and Session ID required' }, { status: 400 })
    }

    // Authorization check
    const userId = await getUserId()
    const access = await getPlayerAccess(userId, playerId)
    if (access.accessLevel === 'none') {
      const authError = generateAuthorizationError(access, 'view', {
        actionDescription: 'view recordings for this student',
      })
      return NextResponse.json(authError, { status: 403 })
    }

    // Verify session exists and belongs to player
    const session = await db.query.sessionPlans.findFirst({
      where: and(eq(sessionPlans.id, sessionId), eq(sessionPlans.playerId, playerId)),
    })

    if (!session) {
      return NextResponse.json({ error: 'Session not found' }, { status: 404 })
    }

    // Get all problem videos for this session (including failed/processing)
    // We'll dedupe and show the best status for each problem/epoch/attempt combo
    const rawVideos = await db.query.visionProblemVideos.findMany({
      where: eq(visionProblemVideos.sessionId, sessionId),
      orderBy: [
        asc(visionProblemVideos.problemNumber),
        asc(visionProblemVideos.epochNumber),
        asc(visionProblemVideos.attemptNumber),
      ],
    })

    // Detect orphaned recordings: any 'recording' status video that has a newer
    // video in the same session is orphaned (we've moved on to another problem).
    // The only legitimate 'recording' is the one with the latest startedAt.
    const maxStartedAt = Math.max(...rawVideos.map((v) => v.startedAt.getTime()))
    const videos = rawVideos.map((video) => {
      if (video.status === 'recording' && video.startedAt.getTime() < maxStartedAt) {
        // This recording was abandoned - treat as no_video
        return { ...video, status: 'no_video' as const }
      }
      return video
    })

    // Deduplicate by problem/epoch/attempt, keeping the "best" status
    // Priority: ready > processing > recording > no_video > failed
    const statusPriority: Record<string, number> = {
      ready: 0,
      processing: 1,
      recording: 2,
      no_video: 3,
      failed: 4,
    }

    const videoMap = new Map<string, (typeof videos)[0]>()

    for (const video of videos) {
      const key = `${video.problemNumber}-${video.epochNumber}-${video.attemptNumber}`
      const existing = videoMap.get(key)

      if (!existing) {
        videoMap.set(key, video)
      } else {
        // Keep the one with better status (lower priority number)
        const existingPriority = statusPriority[existing.status] ?? 999
        const currentPriority = statusPriority[video.status] ?? 999
        if (currentPriority < existingPriority) {
          videoMap.set(key, video)
        }
      }
    }

    // Convert map to sorted array
    const dedupedVideos = Array.from(videoMap.values()).sort((a, b) => {
      if (a.problemNumber !== b.problemNumber) return a.problemNumber - b.problemNumber
      if (a.epochNumber !== b.epochNumber) return a.epochNumber - b.epochNumber
      return a.attemptNumber - b.attemptNumber
    })

    // Transform to response format with epoch/attempt info
    const videoList = dedupedVideos.map((video) => ({
      problemNumber: video.problemNumber,
      partIndex: video.partIndex,
      epochNumber: video.epochNumber,
      attemptNumber: video.attemptNumber,
      isRetry: video.isRetry,
      isManualRedo: video.isManualRedo,
      status: video.status,
      durationMs: video.durationMs,
      fileSize: video.fileSize,
      isCorrect: video.isCorrect,
      startedAt: video.startedAt,
      endedAt: video.endedAt,
      processingError: video.processingError,
    }))

    return NextResponse.json({ videos: videoList })
  } catch (error) {
    console.error('Error listing session videos:', error)
    return NextResponse.json({ error: 'Failed to list videos' }, { status: 500 })
  }
})